Skip to content

ext/pgsql: route pg_copy_from table_name through build_tablename#21985

Open
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/pgsql-copy-from-table-name-escape
Open

ext/pgsql: route pg_copy_from table_name through build_tablename#21985
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/pgsql-copy-from-table-name-escape

Conversation

@iliaal
Copy link
Copy Markdown
Contributor

@iliaal iliaal commented May 8, 2026

Add the table validation added in bug #62978 (which fixed pg_insert, pg_update, pg_select, pg_delete) but missed pg_copy_from. Not updating pg_copy_to since it allows (query) for the table name per ext/pgsql/tests/06_bug73498.phpt.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

Thanks for starting the work, but as hinted. it is preferable you really go through. I ll put some comments.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

I want to see $null_as parameter being challenged too, e.g.

pg_query($db, "DROP TABLE IF EXISTS table1");
  pg_query($db, "DROP TABLE IF EXISTS injected");
  pg_query($db, "CREATE TABLE table1 (v text)");

  $null_as = "X'; CREATE TABLE injected (v text); --";

  pg_copy_from($db, 'victim', ["row\n"], "\t", $null_as);
  
  $r = pg_query($db, "SELECT 1 FROM pg_tables WHERE tablename='injected'");
  var_dump(pg_num_rows($r));  => int(1)

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

Add the table validation added in bug #62978 (which fixed pg_insert, pg_update, pg_select, pg_delete) but missed pg_copy_from. Not updating pg_copy_to since it allows (query) for the table name per ext/pgsql/tests/06_bug73498.phpt.

pg_copy_to needs to go through the grinder too.

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 1f0a1fa to bc6f630 Compare May 9, 2026 13:10
@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

Both null_as and the delimiter now go through PQescapeLiteral, dropping the E'...' wrapper. Bare table_name in pg_copy_to routes through build_tablename. The (query) form per bug 73498 still passes through, but wrapped in an extra paren pair so something like (SELECT 1); DROP TABLE x; becomes a syntax error inside the outer parens instead of escaping out into a second statement.

Comment thread ext/pgsql/pgsql.c Outdated
char *pg_null_as = "\\\\N";
size_t pg_null_as_len = 0;
char *query;
size_t pg_null_as_len = sizeof("\\\\N") - 1;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmmm ... can you try a test that looks like this ?

--TEST--
  pg_copy_to() / pg_copy_from() default null marker is "\N"
  --EXTENSIONS--
  pgsql
  --SKIPIF--
  <?php include("inc/skipif.inc"); ?>
  --FILE--
  <?php
  include('inc/config.inc');
  $t = "pg_copy_default_null";

  $db = pg_connect($conn_str);
  pg_query($db, "DROP TABLE IF EXISTS {$t}");
  pg_query($db, "CREATE TABLE {$t} (id int, v text)");
  pg_query($db, "INSERT INTO {$t} VALUES (1, 'hello'), (2, NULL)");

  $rows = pg_copy_to($db, $t);
  var_dump($rows);

  pg_query($db, "DELETE FROM {$t}");
  var_dump(pg_copy_from($db, $t, $rows));
  var_dump(pg_fetch_all(pg_query($db, "SELECT v FROM {$t} ORDER BY id")));
  ?>
  --CLEAN--
  <?php
  include('inc/config.inc');
  $db = pg_connect($conn_str);
  pg_query($db, "DROP TABLE IF EXISTS pg_copy_default_null");
  ?>
  --EXPECT--
  array(2) {
    [0]=>
    string(8) "1        hello
  "
    [1]=>
    string(4) "2        \N
  "
  }
  bool(true)
  array(2) {
    [0]=>
    array(1) {
      ["v"]=>
      string(5) "hello"
    }
    [1]=>
    array(1) {
      ["v"]=>
      NULL
    }
  }

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from bc6f630 to 6fbe705 Compare May 9, 2026 15:26
@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

Ah yes, good catch. Just \\N is enough here.

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

other things to think about (no rush, won t be committed today), what happens when query has already parens, your test need stricter output expectations ; i.e. pg_copy_from(): %s ... your new error messages need to be more specific, aka which parameter had failed.

Comment thread ext/pgsql/pgsql.c Outdated
if (!escaped_delimiter || !escaped_null_as) {
php_error_docref(NULL, E_WARNING, "Failed to escape COPY parameters");
if (escaped_delimiter) PQfreemem(escaped_delimiter);
if (escaped_null_as) PQfreemem(escaped_null_as);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: code style in php-src is new line and braces even with just 1 line block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah 😆 ok something to tweak

@iliaal
Copy link
Copy Markdown
Contributor Author

iliaal commented May 9, 2026

other things to think about (no rush, won t be committed today), what happens when query has already parens, your test need stricter output expectations ; i.e. pg_copy_from(): %s ... your new error messages need to be more specific, aka which parameter had failed.

If already has parens shouldn't be an issue would just be double parens, I tested locally but let me double check and add to test.

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 6fbe705 to 7ff2c22 Compare May 9, 2026 16:21
Comment thread ext/pgsql/pgsql.c Outdated

char *escaped_delimiter = PQescapeLiteral(pgsql, ZSTR_VAL(pg_delimiter), 1);
if (!escaped_delimiter) {
php_error_docref(NULL, E_WARNING, "Failed to escape delimiter");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as mentioned, you have the specific information available, e.g. here pg_delimiter.

Copy link
Copy Markdown
Contributor Author

@iliaal iliaal May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I didn't quite get the ask here, you want the error message to include the delimiter itself, seems of limited value to me, but can be added I suppose

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes just for clarification's sake.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or even better...
zend_string *msgbuf = _php_pgsql_trim_message(PQerrorMessage(pgsql));
php_error_docref...
zend_string_release(msgbuf);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied PQerrorMessage across all four errors and added params where needed, also for consistency promoted table escape error from notice to warning

@devnexen
Copy link
Copy Markdown
Member

devnexen commented May 9, 2026

You covered most of the points I ll have a fresh look in the following days. Cheers !

iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 7ff2c22 to a6dfbfb Compare May 9, 2026 17:13
iliaal added a commit to iliaal/php-src that referenced this pull request May 9, 2026
The COPY query embedded the table_name argument with a raw "%s" and the
delimiter and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978), and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498, but wraps it in an extra paren pair
so a string like (SELECT 1); DROP TABLE x; -- becomes a syntax error
inside the outer parens instead of escaping out into a second statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from a6dfbfb to 29fce0c Compare May 9, 2026 17:25
Comment thread ext/pgsql/pgsql.c Outdated
smart_str_appendc(&querystr, '(');
smart_str_append(&querystr, table_name);
smart_str_appendc(&querystr, ')');
} else if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(re)using build_tablename narrows what these accept. old code passed table_name through raw via spprintf so callers could tack on any COPY syntax. now anything that isn't a plain identifier or schema.table gets wrapped as one quoted name and fails. Before:

  • "mytable (col1, col2)" column list
  • "ONLY mytable"
  • '"my.table"' quoted name with a dot, memchr splits it

the (query) branch covers column lists but not ONLY or dotted quoted names. either gate build_tablename behind a simple-identifier check and error out, or add
explicit columns/only params.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a strict gate ahead of build_tablename: bare table_name must match [A-Za-z_][A-Za-z0-9_]* or schema.table, anything else (column list, ONLY, quoted, quoted-with-literal-dot) returns false with a warning pointing at the argument. Column lists and ONLY for pg_copy_to remain reachable via the (query) form.

Comment thread ext/pgsql/pgsql.c
}
smart_str querystr = {0};
smart_str_appends(&querystr, "COPY ");
if (build_tablename(&querystr, pgsql, table_name) == FAILURE) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gate applied here.


$rows = pg_fetch_all(pg_query($db, 'SELECT v FROM pg_copy_from_other')) ?: [];
var_dump($rows);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a case that tries to close the wrapper early, like '(SELECT 1)); DROP TABLE pg_copy_to_qsource; --', plus the pg_tables check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the wrapper-close test case plus a paren-balance check on the (query) input: must start with (, end with ), and depth must reach 0 only at the final character. The original (SELECT 1); DROP...; -- case also hits the new gate, so its expected output flipped from a Postgres syntax error to the validation warning.

iliaal added a commit to iliaal/php-src that referenced this pull request May 11, 2026
The COPY query embedded table_name with a raw "%s" and the delimiter
and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Route bare table names
through build_tablename (the same helper pg_insert/update/select/delete
have used since bug #62978) and pass the delimiter and null marker
through PQescapeLiteral. pg_copy_to keeps the parenthesised (query)
source form documented in bug 73498; the input is now SQL-aware
paren-balance checked (single, double, and dollar-quoted literals are
tracked) and rejected if depth returns to 0 before end-of-string, so
the user-supplied string cannot close the wrapper early and inject a
trailing statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 29fce0c to b97ce88 Compare May 11, 2026 11:54
iliaal added a commit to iliaal/php-src that referenced this pull request May 11, 2026
The COPY query embedded table_name with a raw "%s" and the delimiter
and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Restrict the bare
table_name argument to a plain identifier or schema.table (each side
matching [A-Za-z_][A-Za-z0-9_]*) and route it through build_tablename,
the same helper pg_insert/update/select/delete have used since bug
#62978. Reject anything else with a clear warning so previously
silent-fail cases (column lists, ONLY, quoted identifiers) point at
the table_name argument instead of surfacing as a Postgres relation
lookup error. Pass the delimiter and null marker through
PQescapeLiteral. pg_copy_to keeps the parenthesised (query) source
form documented in bug 73498; the input is now SQL-aware paren-balance
checked (single, double, and dollar-quoted literals are tracked) and
rejected if depth returns to 0 before end-of-string, so the
user-supplied string cannot close the wrapper early and inject a
trailing statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from b97ce88 to 5bf0309 Compare May 11, 2026 12:04
The COPY query embedded table_name with a raw "%s" and the delimiter
and null marker inside literal E'..' wrappers, so caller-supplied
strings could break out and run side queries. Restrict the bare
table_name argument to a plain identifier or schema.table (each side
matching [A-Za-z_][A-Za-z0-9_]*) and route it through build_tablename,
the same helper pg_insert/update/select/delete have used since bug
#62978. Reject anything else with a clear warning so previously
silent-fail cases (column lists, ONLY, quoted identifiers) point at
the table_name argument instead of surfacing as a Postgres relation
lookup error. Pass the delimiter and null marker through
PQescapeLiteral. pg_copy_to keeps the parenthesised (query) source
form documented in bug 73498; the input is required to start with
"(", end with ")", and the matching close paren must be the final
character, so the user-supplied string cannot close the wrapper early
and inject a trailing statement.

Closes phpGH-21985
@iliaal iliaal force-pushed the fix/pgsql-copy-from-table-name-escape branch from 5bf0309 to 9b2015b Compare May 11, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants